Skip to content

perf(mobile): compressed websocket frames for iOS - #4794

Open
t3dotgg wants to merge 5 commits into
mainfrom
t3code/improve-mobile-data-loading
Open

perf(mobile): compressed websocket frames for iOS#4794
t3dotgg wants to merge 5 commits into
mainfrom
t3code/improve-mobile-data-loading

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Jul 28, 2026

Copy link
Copy Markdown
Member

iPhones were left out of the websocket compression we shipped in #4705. React Native's iOS WebSocket is backed by SocketRocket, which never offers permessage-deflate, so every frame to the iOS app still crossed the wire uncompressed while Android (OkHttp offers the extension by default) got the win for free.

This adds a small Apple-only Expo module backed by URLSessionWebSocketTask, which offers permessage-deflate in its handshake automatically. A thin JS wrapper exposes the W3C WebSocket surface Effect's Socket.fromWebSocket drives, and the mobile runtime injects it as the Socket.WebSocketConstructor on iOS only. Android and iOS binaries predating the module fall back to the global WebSocket unchanged, so older TestFlight builds keep working.

Details worth noting:

  • maximumMessageSize is raised to 128 MB on the URLSession task. The default receive cap is 1 MB, and socket-fallback snapshot frames can exceed that.
  • On abnormal termination the module synthesizes close code 1006 after the error event, matching browser semantics so the connection supervisor classifies it as a transport error and reconnects.
  • RN's global WebSocket is untouched; only the Effect runtime socket switches implementation.

Tests: vp test run src/lib/nativeWebSocket.test.ts (6 tests: id routing, once-listeners, binary round-trip, idempotent close), mobile typecheck, lint.

Built with Claude Fable 5 in Claude Code.

🤖 Generated with Claude Code


Note

Medium Risk
Swaps the transport for all Effect-driven iOS WebSockets (reconnect/close semantics and large-frame handling must stay correct), but scope is limited to mobile relay sockets with explicit fallbacks and unit tests on the JS bridge.

Overview
iOS relay WebSockets now use a native URLSessionWebSocketTask path so the client can negotiate permessage-deflate with the server (SocketRocket behind RN’s global WebSocket cannot). Android and older iOS builds without the module keep the existing global WebSocket.

A new Apple-only Expo module (T3WebSocket) manages connections by id, raises the receive cap to 128 MB for large snapshot frames, clears custom URLProtocol classes so dev proxies don’t break upgrades, and on transport failure emits error + synthetic close 1006 for Effect’s reconnect logic. A thin JS NativeWebSocket implements the subset Effect needs (events, send, close, multi-connection routing), and runtime.ts injects it as Socket.WebSocketConstructor when loadNativeWebSocketConstructor() succeeds.

Reviewed by Cursor Bugbot for commit a7adbc1. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add compressed WebSocket native module for iOS using URLSessionWebSocketTask

  • Introduces a new native iOS module T3WebSocketModule built on URLSessionWebSocketTask, supporting text/binary messaging, subprotocols, and per-connection events (onOpen, onMessage, onClose, onError) keyed by caller-supplied integer ids.
  • Adds a JS-side NativeWebSocket class that mirrors browser WebSocket semantics (readyState transitions, once listeners, idempotent close, base64↔Uint8Array conversion for binary frames).
  • Updates runtime.ts to dynamically select the native WebSocket constructor on iOS when available, falling back to the global constructor on other platforms.
  • Maximum message size is capped at 128 MiB; abnormal connection termination emits an onClose with code 1006.

Macroscope summarized a7adbc1.

Summary by CodeRabbit

  • New Features

    • Added a native iOS WebSocket implementation for improved mobile connectivity.
    • Supports text and binary messaging, connection lifecycle events, custom protocols, and graceful closure.
    • Automatically uses the native implementation when available, with fallback support to the standard WebSocket.
  • Bug Fixes

    • Improved handling of connection states, errors, close events, and late connection callbacks.
  • Tests

    • Added comprehensive coverage for native WebSocket connections, messaging, listeners, and lifecycle behavior.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b9bf6195-023b-4445-b573-5bf5ad15a1f6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/improve-mobile-data-loading

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 28, 2026
Comment thread apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift
Comment thread apps/mobile/src/lib/nativeWebSocket.ts Outdated
Comment thread apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift
@macroscopeapp

macroscopeapp Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a new native iOS module for WebSocket handling with compression support. The addition of new native networking infrastructure (~400+ lines of new Swift and TypeScript code) that changes runtime WebSocket behavior warrants human review.

You can customize Macroscope's approvability policy. Learn more.

@t3dotgg
t3dotgg force-pushed the t3code/improve-mobile-data-loading branch from 9283c97 to 4e70318 Compare July 28, 2026 23:53
Comment thread apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift
t3dotgg and others added 4 commits July 28, 2026 18:12
React Native's iOS WebSocket is backed by SocketRocket, which never offers
permessage-deflate, so the server-side negotiation from #4705 only benefited
Android (OkHttp offers it by default). This adds a URLSessionWebSocketTask-
backed Expo module (URLSession offers the extension in its handshake) and
injects it as the Effect Socket constructor on iOS only, with a fallback to
the global WebSocket on Android and on binaries predating the module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bots caught the emit-after-remove race: delegate callbacks deferred the
emit onto the serial queue then removed the emitter synchronously, so
close/error events never reached JS. Emits now run synchronously on the
queue before removal. Also cleans up activeSockets when connect throws,
and imports "expo" lazily so test import chains touching runtime.ts do
not evaluate expo's dev-only setup (CI __DEV__ failure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified on the simulator: the socket completed its 101 (with
permessage-deflate negotiated) and then died instantly with
NSURLErrorDomain -1005, so JS never saw an open event and the supervisor
reconnected every 15s. The dev client registers custom URLProtocol
classes for its network inspector; they intercept URLSession traffic and
do not understand the WebSocket upgrade. Clearing protocolClasses on the
session config fixes it: one stable socket, no reconnects over 60s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
URLSession retains its delegate until invalidated, so cancelAll() left the
connections object and its session alive on every module destroy/recreate
cycle. Teardown now invalidates and clears the session; the next open()
builds a fresh one.

Verified on the simulator: environment connects, holds a single socket for
60s, and reconnects cleanly after a full app teardown and relaunch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotgg force-pushed the t3code/improve-mobile-data-loading branch from 59ce135 to 95f6b12 Compare July 29, 2026 01:13

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 95f6b12. Configure here.

Comment thread apps/mobile/src/lib/nativeWebSocket.ts
close() during CONNECTING (an Effect open-timeout or scope teardown) leaves
the native connect in flight, so its didOpen could still land and flip the
socket back to OPEN, firing open handlers on a socket the caller had already
given up on. handleOpen now only transitions from CONNECTING.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift (1)

30-37: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Report dropped sends instead of returning silently.

sendBinary drops the frame when base64 decoding fails. send (line 95) drops the frame when no task exists for id. The JS adapter receives no signal in either case, so a caller sees a successful send() for data that never left the device. Emit onError on both paths.

♻️ Proposed change
     Function("sendBinary") { (id: Int, base64: String) in
-      guard let data = Data(base64Encoded: base64) else { return }
+      guard let data = Data(base64Encoded: base64) else {
+        self.connections.reportError(id: id, message: "Invalid base64 payload")
+        return
+      }
       self.connections.send(id: id, message: .data(data))
     }

Add the helper and the missing-task signal in T3WebSocketConnections:

func reportError(id: Int, message: String) {
  queue.async { self.emitLocked(id: id, event: "onError", payload: ["message": message]) }
}

func send(id: Int, message: URLSessionWebSocketTask.Message) {
  queue.async {
    guard let task = self.tasksById[id] else {
      self.emitLocked(id: id, event: "onError", payload: ["message": "Socket is not open"])
      return
    }
    // ... existing send
  }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift` around lines 30
- 37, Update sendBinary and T3WebSocketConnections.send to report failed sends
through onError instead of silently returning: use a
T3WebSocketConnections.reportError helper for invalid base64 input, and emit an
onError event with “Socket is not open” when no task exists for the requested
id. Preserve the existing send behavior for valid data and open sockets.
apps/mobile/src/lib/nativeWebSocket.test.ts (1)

106-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for onError and for protocol forwarding.

The suite does not exercise handleError or the protocols argument. handleError dispatches an Error instance rather than an event object, which is the least obvious part of the adapter contract. A short test for each closes that gap.

💚 Suggested tests
it("dispatches native errors and forwards protocols", async () => {
  const construct = await loadNativeWebSocketConstructor();
  const socket = construct!("wss://example.test/ws", ["t3"]);
  expect(lastCall("connect")!.args[2]).toEqual(["t3"]);

  const id = connectionIdOf(socket);
  const errors: unknown[] = [];
  socket.addEventListener("error", (event) => errors.push(event));
  mocks.emit("onError", { id, message: "boom" });
  expect(errors[0]).toBeInstanceOf(Error);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/lib/nativeWebSocket.test.ts` around lines 106 - 131, Add test
coverage in the native WebSocket test suite for the constructor’s protocols
argument and the onError path handled by handleError. Instantiate the socket
with a protocol list and assert connect receives it, then emit onError, capture
the error event, and assert the dispatched value is an Error instance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/mobile/src/lib/nativeWebSocket.ts`:
- Around line 46-63: Update attachNativeListeners so nativeListenersAttached is
set to true only after all four module.addListener registrations complete
successfully. Keep the flag false when any registration throws, allowing a later
invocation to retry attaching listeners and deliver events to sockets.

---

Nitpick comments:
In `@apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift`:
- Around line 30-37: Update sendBinary and T3WebSocketConnections.send to report
failed sends through onError instead of silently returning: use a
T3WebSocketConnections.reportError helper for invalid base64 input, and emit an
onError event with “Socket is not open” when no task exists for the requested
id. Preserve the existing send behavior for valid data and open sockets.

In `@apps/mobile/src/lib/nativeWebSocket.test.ts`:
- Around line 106-131: Add test coverage in the native WebSocket test suite for
the constructor’s protocols argument and the onError path handled by
handleError. Instantiate the socket with a protocol list and assert connect
receives it, then emit onError, capture the error event, and assert the
dispatched value is an Error instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 13b4bc2a-5a6e-4d6f-9422-ebf4f5d26330

📥 Commits

Reviewing files that changed from the base of the PR and between 694f8d1 and a7adbc1.

📒 Files selected for processing (6)
  • apps/mobile/modules/t3-websocket/expo-module.config.json
  • apps/mobile/modules/t3-websocket/ios/T3WebSocket.podspec
  • apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift
  • apps/mobile/src/lib/nativeWebSocket.test.ts
  • apps/mobile/src/lib/nativeWebSocket.ts
  • apps/mobile/src/lib/runtime.ts

Comment on lines +46 to +63
function attachNativeListeners(module: T3WebSocketNativeModule) {
if (nativeListenersAttached) {
return;
}
nativeListenersAttached = true;
module.addListener("onOpen", (payload) => {
activeSockets.get(payload.id)?.handleOpen();
});
module.addListener("onMessage", (payload) => {
activeSockets.get(payload.id)?.handleMessage(payload.data ?? "", payload.binary === true);
});
module.addListener("onClose", (payload) => {
activeSockets.get(payload.id)?.handleClose(payload.code ?? 1006, payload.reason ?? "");
});
module.addListener("onError", (payload) => {
activeSockets.get(payload.id)?.handleError(payload.message ?? "WebSocket error");
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Set the attached flag only after the listeners attach.

If the first module.addListener call throws, nativeListenersAttached stays true. No later socket then receives native events, and every socket stays in CONNECTING until Effect's open timeout.

🛡️ Proposed change
   if (nativeListenersAttached) {
     return;
   }
-  nativeListenersAttached = true;
   module.addListener("onOpen", (payload) => {
@@
   module.addListener("onError", (payload) => {
     activeSockets.get(payload.id)?.handleError(payload.message ?? "WebSocket error");
   });
+  nativeListenersAttached = true;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function attachNativeListeners(module: T3WebSocketNativeModule) {
if (nativeListenersAttached) {
return;
}
nativeListenersAttached = true;
module.addListener("onOpen", (payload) => {
activeSockets.get(payload.id)?.handleOpen();
});
module.addListener("onMessage", (payload) => {
activeSockets.get(payload.id)?.handleMessage(payload.data ?? "", payload.binary === true);
});
module.addListener("onClose", (payload) => {
activeSockets.get(payload.id)?.handleClose(payload.code ?? 1006, payload.reason ?? "");
});
module.addListener("onError", (payload) => {
activeSockets.get(payload.id)?.handleError(payload.message ?? "WebSocket error");
});
}
function attachNativeListeners(module: T3WebSocketNativeModule) {
if (nativeListenersAttached) {
return;
}
module.addListener("onOpen", (payload) => {
activeSockets.get(payload.id)?.handleOpen();
});
module.addListener("onMessage", (payload) => {
activeSockets.get(payload.id)?.handleMessage(payload.data ?? "", payload.binary === true);
});
module.addListener("onClose", (payload) => {
activeSockets.get(payload.id)?.handleClose(payload.code ?? 1006, payload.reason ?? "");
});
module.addListener("onError", (payload) => {
activeSockets.get(payload.id)?.handleError(payload.message ?? "WebSocket error");
});
nativeListenersAttached = true;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/src/lib/nativeWebSocket.ts` around lines 46 - 63, Update
attachNativeListeners so nativeListenersAttached is set to true only after all
four module.addListener registrations complete successfully. Keep the flag false
when any registration throws, allowing a later invocation to retry attaching
listeners and deliver events to sockets.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant